iT邦幫忙

2026 iThome 鐵人賽

DAY 7
0
Software Development

在 AI Compiler 工程師的路上系列 第 7

Day06:Decode/Extend Attention 的 RVV

  • 分享至 

  • xImage
  •  

前幾天看了 attention 後端的 Python 到 C++ 流程,也解釋 linear、norm、activation、RoPE。今天會看 decode attention 和 extend attention。它們都在做 attention,但資料流、分塊大小、KV cache 讀取方式和 softmax 累積方式不一樣。

本篇大綱

  • Llama 3 的 attention 呼叫的流程
  • 再用 FlashDecoding / FlashAttention 的角度區分 decode 和 extend 資料流
  • 再看 KV cache、req_to_tokenseq_lens 怎麼決定 kernel 要讀哪裡
  • 接著拆 decode kernel 裡的 QK dot product、softmax、V accumulation
  • 然後拆 extend kernel 裡的前綴 / current token 分塊
  • 最後整理 RVV attention kernel 的最佳化取捨:分塊大小、thread buffer、INT8 KV 縮放係數、cache prefetch、記憶體頻寬

Llama 3 的 attention 呼叫的流程

LlamaAttention.forward
-> self.attn(q, k, v, forward_batch)
-> RadixAttention.forward
-> forward_batch.attn_backend.forward
-> RVVAttnBackend.forward_decode / forward_extend
-> torch.ops.sgl_kernel.decode_attention_cpu / extend_attention_cpu
-> sgl-kernel/csrc/cpu/riscv64/decode.cpp / extend.cpp

LlamaAttention.forward 會完成 Q/K/V projection、RoPE,再呼叫 self.attn(...)。它不在這裡判斷 RVV。SGLang 啟動 CPU backend 時會先檢查 AMX / RVV 支援;選到 rvv 後,ForwardBatch 會帶著建立好的 RVVAttnBackendAttentionBackend.forward 再依 forward_mode 呼叫 forward_decodeforward_extend

torch.ops.sgl_kernel.* 已經連到安裝好的 C++ extension。我目前會在啟動推論前編譯 sgl-kernel,環境有 clang-19 時優先使用 clang-19 / clang++-19來編譯 RVV Intrinsic 寫的 AI 運算子,每個 token 的 forward 只會呼叫已載入的 operator,不會重新編譯 C++ kernel。

Decode 和 extend 的差別

Decode attention 用在產生下一個 token。每個請求已經有一段 KV cache,這一輪有新的 Q/K/V,要把新 K/V 寫進 cache,再用 Q 加到整段前綴。

簡化流程

new q, new k, new v
-> write new k/v into KV cache
-> read old + new K from KV cache
-> score = q @ K
-> softmax(score)
-> output = softmax(score) @ V

Extend attention 用在 prefill 或 chunked prefill,它一次處理多個新 token。它要同時看 prefix cache 和這一輪 extend 的 token。

簡化流程

q_extend, k_extend, v_extend
prefix K/V in KV cache
-> attention over prefix
-> attention over current extend tokens
-> write output

Decode 比較像一個新 query 去掃既有 KV cache,Extend 比較像一小塊 Q 對 前綴和本輪 K/V 做 tiled attention。

FlashAttention / FlashDecoding

FlashAttention 的核心是 IO-aware tiling。它把 Q、K、V 分塊,在小型 score tile 上更新 online softmax,不把完整的 attention score matrix 寫回主記憶體。

extend.cpp 採用相同的演算法結構,可以稱為 FlashAttention-style tiled attention

  • BLOCK_M 決定一次處理幾列 query。
  • BLOCK_N 決定一次讀幾個 key / value token。
  • s_i[BLOCK_M x BLOCK_N] 只保存目前的 score tile。
  • m_accl_accv_prime 跨 tile 保存 running max、exponential sum 與 value accumulator。
  • Stage 1 讀 prefix KV cache;Stage 2 處理本輪 extend token,並套用 causal mask。

Decode 時通常只有一列 query。只切 Q tile 很難提供足夠的平行工作,因此 decode.cpp 會再把長 KV sequence 切成 num_kv_splits。每個 split 獨立掃過自己的 KV block,產生 partial output 和 log-sum-exp,decode_accumulate_kv_splits 最後依 long-sum-exp(LSE) 重新加權合併。這是 FlashDecoding-style split-KV attention 的主要結構。
https://ithelp.ithome.com.tw/upload/images/20260807/20183319XWRTHlymfk.png

左圖的 KV splits 可以交給不同 CPU 工作項目平行計算,RVV 負責每個 split 裡的 QK、softmax reduction 與 SV。右圖則在同一個 query tile 上反覆讀取 K/V tile,用 online softmax 更新同一組 accumulator。

我這裡實作了 FlashAttention 與 FlashDecoding 的演算法結構,再用 RVV intrinsic、CPU thread、SGLang 的非連續 KV cache layout 完成硬體對應。

SGLang 的 KV cache 索引

attention kernel 不能只看 tensor 本身。SGLang推論服務裡有請求 pool 和token pool, kernel 要透過 映射 tensor 找到每個請求的 token 位置。

幾個重要 tensor

req_to_token:     [max_num_reqs, max_context_len]
req_pool_indices: [num_seqs]
seq_lens:         [num_seqs]
loc:              [num_seqs]

req_pool_indices[b] 會告訴 kernel 第 b 個 sequence 對應到請求 pool 的哪一列

req_to_token[req_idx, t] 會告訴 kernel 第 t 個 token 在 KV cache 裡的位置

seq_lens[b] 告訴 kernel 這個 sequence 現在有多長

loc[b] 是 decode 這輪新 K/V 要寫入的 cache 位置

所以 C++ kernel 的參數看起來很多

decode_attention_cpu(
    query,
    k_buffer,
    v_buffer,
    output,
    key,
    value,
    loc,
    attn_logits,
    req_to_token,
    req_pool_indices,
    seq_lens,
    sm_scale,
    logit_cap)

這些參數都在描述 SGLang 執行環境的請求狀態,RVV kernel 需要靠它們找到每個請求對應的 KV token。

FlashDecoding 在 decode.cpp 的入口

decode_attention_cpu 會先檢查形狀

CHECK_DIM(3, query);
CHECK_DIM(3, k_buffer);
CHECK_DIM(3, v_buffer);
CHECK_DIM(3, key);
CHECK_DIM(3, value);

它期待

query:       [num_tokens, num_heads, head_size]
k_buffer:    [max_total_num_tokens, num_heads_kv, head_size]
v_buffer:    [max_total_num_tokens, num_heads_kv, head_size_v]
attn_logits: [num_seqs, num_heads, num_kv_splits, head_size_v + 1]

接著先把這輪新的 K/V 寫進 cache

decode_set_kv_buffer(
    k_buffer,
    v_buffer,
    key,
    value,
    loc,
    num_seqs,
    num_heads_kv,
    head_size,
    head_size_v,
    ...);

然後依照 MHA 或 GQA 路徑呼叫不同 kernel

if (num_heads == num_heads_kv) {
  decode_attention_kernel_impl<...>(...);
} else {
  decode_attention_grouped_kernel_impl<...>(...);
}

MHA 裡 query head 和 KV head 一樣多。GQA/MQA 裡多個 query head 會共用較少的 KV head,所以 grouped kernel 需要處理 head grouping。

Decode 裡的 QK dot product

decode kernel 會把 KV sequence 切成 block。每個 block 裡,先透過 req_to_token 拿到這段 token 對應的 KV cache 位置

const index_t* cur_indices =
    req_to_token + req_pool_id * max_context_len + n;

接著做

s_i = Q @ K_block

這裡會走 index_gemm_kernel_ntnt 可以讀成 A normal、B transposed 的 GEMM 形狀。K 來自 KV cache,並且透過 cur_indices 做間接索引。

BF16 / FP16 路徑裡,tiny GEMM 會看到 RVV intrinsic

for (int64_t k = 0; k < K; k += vl) {
  vl = __riscv_vsetvl_e32m1(K - k);
  ...
  vc[m * COLS + n] =
      __riscv_vfmacc_vv_f32m1_tu(vc[m * COLS + n], va, vb[n], vl);
}

這裡用 _tu 是因為累加器要跨多輪 K-迴圈保留。當最後一輪 vl 變小時,尾端 lane 不能把舊累加器破壞掉。

Decode 裡的 softmax 和 V accumulation

decode 時,每個 query head 對當前 KV sequence 產生的 score 是長度為 seq_len 的向量,不是 prefill 那種 query-by-key 大矩陣。sequence length 變長時,kernel 仍不希望一次保留整條 score 向量,因此會在 KV block 上做分塊 softmax。

核心概念是維持兩個統計

m_prime: current max over scores
s_prime: exp(score - max) 的累積和

每個 KV block 算完 score 後, kernel 會更新 max 和 sum,並把前面累積的 v_prime 重新縮放。

接著做

v_prime = exp(score - m_new) @ V_block + old_v_prime * scale

這裡的 s_delta 是以新 running max 穩定化後的未正規化權重,不是已經除以 softmax denominator 的機率。舊累積值的 scaleexp(m_old - m_new)。每個非空 split 結束時會先用 running sum 正規化 partial value 並保存 LSE;decode_accumulate_kv_splits 再依各 split 的 LSE 加權合併,得到完整 sequence 的輸出。

程式碼裡會呼叫 index_gemm_kernel_nn

index_gemm_kernel_nn<scalar_t, kv_t, index_t>(
    /* A   */ s_delta,
    /* B   */ v_buffer + head_kv_id * v_strideH,
    /* C   */ v_prime,
    /* ind */ cur_indices,
    ...
);

最後每個 KV split 會產生一個 partial output。decode_accumulate_kv_splits 再把 splits 合併成最後 output。

這就是 attn_logits 為什麼形狀是

[num_seqs, num_heads, num_kv_splits, head_size_v + 1]

最後多出來的 +1 用來放每個 split 的 log-sum-exp 相關值,合併 splits 時會用到。

INT8 KV cache多了縮放係數

INT8 decode 路徑會多傳

k_scale_buf
v_scale_buf
k_scale
v_scale

這裡有兩種不同的 scale 模式,需要分開讀

  • k_scale / v_scale 是靜態全局 scale,同一個值套用到對應 K 或 V 量化路徑。
  • k_scale_buf / v_scale_buf 儲存動態 per-token / per-KV-head scale,kernel 會依 token slot 間接索引。

新的 K/V 如果是 floating-point 資料型別,kernel 可依路徑計算動態 scale、quantize 成 int8,並寫入 scale buffer;如果輸入已經是 int8,則必須同時提供與該資料編碼相符的 scale。靜態全局 scale 與動態 buffer 不應被寫成同一種 per-token 機制。

在 dynamic per-token scale buffer 存在時,kernel 會在讀 KV block 前 pre-gather 每個 token 的縮放係數

k_scales_block[j] =
    load_valid_int8_kv_scale(k_scale_buf, cur_indices[j], num_heads_kv, head_kv_id, "K");

這表示 INT8 attention 的效能不只看 int8 載入,縮放係數 buffer 的讀取、dequant、per-token 縮放係數資料配置都會影響 kernel 。

FlashAttention 在 extend.cpp 的分塊設計

Extend kernel 在 extend.cpp,一開始就定義了和 VLEN 相關的 block

static constexpr int EXTEND_BLOCK_N =
    static_cast<int>(__riscv_v_fixed_vlen / 8);

static constexpr int BLOCK_M = 1024 / EXTEND_BLOCK_N;

這裡還需要實驗調參數目前先設 4KB。

Score tile s_i[BLOCK_M x BLOCK_N] 保持約 4KB

如果 K1 的 VLEN 是 256

EXTEND_BLOCK_N = 256 / 8 = 32
BLOCK_M = 1024 / 32 = 32
s_i = 32 x 32 x 4 bytes = 4096 bytes

目前只把 score tile s_i 控制在 4KB 左右,不是每個 thread 的全部 arena 只有 4KB。v_primeBtmpk_transv_buf 還會依 head size 額外佔用空間,這裡展示 VLEN 如何影響分塊,但總 scratch 大小要用 compute_buffer_size_per_thread 的完整配置來算。

Extend 的 thread-local buffer

compute_buffer_size_per_thread 會配置幾塊 buffer

s_i:     float[BLOCK_M * BLOCK_N]
v_prime: float[BLOCK_M * head_size_v]
Btmp:    scalar_t[BLOCK_N * max(head_size, head_size_v)]
k_trans: scalar_t[head_size * BLOCK_N]
v_buf:   scalar_t[BLOCK_N * head_size_v]

這些 buffer 都在每個 thread 自己的 arena 裡,原因是 extend attention 會做 tiled QK、softmax、SV,過程中需要 temporary 分塊。

k_trans 這段值得看。KV cache 裡的 K資料配置通常不符合 kernel 最想要的存取形狀,extend kernel 會先把 K gather / transpose 成比較適合 GEMM 的 分塊,再做 QK。

Extend 的 prefix 和 current token

Extend kernel 裡會先算幾個長度

seq_len = seq_lens[b];
extend_len = extend_seq_lens[b];
prefix_len = seq_len - extend_len;

Prefix 是已經在 KV cache 裡的 token。Current extend 是這一輪新進來的 token。

Stage 1 會處理 prefix

for n_start in prefix:
  gather K from k_buffer through req_to_token
  Q @ K
  online softmax update
  gather V from v_buffer
  accumulate output

Stage 2 會處理 current extend token

for n_start in current extend:
  read K from k_extend
  apply causal mask
  online softmax update
  read V from v_extend
  accumulate output

這裡和 decode 最大差別是:extend 一次處理多個 query row,所以它需要 BLOCK_M x BLOCK_N 的 score 分塊。

online softmax_update_row

extend.cpp 裡的 softmax_update_row 是一個很好的 RVV 例子。

它先找 block 裡的 max

vfloat32m8_t v_max = __riscv_vfmv_v_f_f32m8(-INFINITY, vl_max);
...
v_max = __riscv_vfmax_vv_f32m8_tu(v_max, v_max, v_s, vl);

接著更新 global max

float m_new = std::max(m_acc, m_block);
float alpha = expf(m_acc - m_new);
m_acc = m_new;

然後把已經累積的 v_prime 乘上 alpha

v_vp = __riscv_vfmul_vf_f32m8(v_vp, alpha, vl);

最後把這一 block 的 score 轉成 exp,更新 l_acc

v_s = __riscv_vfsub_vf_f32m8(v_s, m_new, vl);
vfloat32m8_t v_exp = vfexp_f32m8(v_s, vl);
...
l_acc = l_acc * alpha + l_block;

這段把昨天的 reduction、近似函式、尾端 handling 都放進 attention,softmax 的數值穩定性靠 m_accl_acc 維持,各個 block 的結果還要合併到同一組 running max / running sum。

Attention效能測試要看什麼

Attention效能測試需要比 linear 更小心,因為它同時受多個因素影響

  • sequence length
  • batch 大小
  • number of heads / KV heads
  • head_size / head_size_v
  • KV cache 資料型別:BF16、FP16、INT8
  • req_to_token 的 access 模式
  • KV split 數量
  • prefix length 和 extend length
  • cache prefetch 是否有效

看效能分析器時,可以先確認

sgl-kernel::decode_attention_cpu
sgl-kernel::extend_attention_cpu
sgl-kernel::decode_attention_int8_cpu
sgl-kernel::extend_attention_int8_cpu

然後再看單批次 prefill / decode 延遲。Decode 和 extend 的 bottleneck 可能不同。Decode 常受 KV cache 串流讀取和小批次額外成本 影響,extend 則更看 分塊 buffer、QK/SV GEMM、softmax update

今天先走到這裡

今天把 SGLang RVV attention kernel 的主線走完

  • Decode 會先把新 K/V 寫進 KV cache,再用 Q attend 到既有 KV cache
  • Extend 會同時處理 prefix cache 和本輪 extend token,因此需要 BLOCK_M x BLOCK_N 分塊
  • req_to_tokenreq_pool_indicesseq_lens 把 SGLang 請求狀態交給 kernel
  • Decode 裡的 QK / SV 可以看成 indexed GEMM,KV cache 透過 token 位置間接讀取
  • Extend 的 EXTEND_BLOCK_N = VLEN / 8,K1 VLEN=256 時 block_N 會是 32,score 分塊約 4KB
  • online softmax 需要維持 m_acc / l_acc,並在每個 block 重新縮放已累積的 value

到這裡我們把 SGLang RVV 後端的整合和主要 AI 運算子都已經看過,發現手寫 RVV 運算子還蠻累人的,所以明天開始進入到 AI compiler 的範圍,看 torch.compile 和 PyTorch Inductor,看看能不能讓編譯器自動產生 RVV C++,減少手寫自訂運算子的負擔。

參考資料


上一篇
Day05:Norm、Activation、RoPE 的 RVV 寫法
下一篇
Day07:從 torch.compile 看 PyTorch CPU 編譯流程
系列文
在 AI Compiler 工程師的路上21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言